Skip to content

feat(studio,core): a volume and a living meter on the group row - #3290

Merged
vanceingalls merged 2 commits into
mainfrom
wa-23g-bus-strip
Aug 21, 2026
Merged

feat(studio,core): a volume and a living meter on the group row#3290
vanceingalls merged 2 commits into
mainfrom
wa-23g-bus-strip

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

Summary

B7 — the group bus strip: a volume slider, a level bar that moves with the sound, and the words "Too loud" when it clips. Deliberately minimal per the casual-user design constraints (groups doc §5): no dB numbers, no peak-hold readout, no routing row.

  • Transport (core): groupInput() now routes each group through input -> [FX chain or dry passthrough] -> output -> master, with one AnalyserNode per group tapped off output (post-FX, so the meter reads what the bus actually outputs) — fftSize 256. groupLevel(groupId) returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer, or null when the group is idle/unknown. The runtime posts group-levels messages only while playing.
  • Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's shape); useGroupLevel throttles re-renders to ~33ms. TimelineGroupBusStrip renders in the group row's own lane area — drag writes live, release commits one undo entry (packages/studio/src/hooks/timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to stay under the 600-line cap). "Too loud" holds for ~2s after the last clipped block.
  • Fixed two pre-existing group-routing tests in webAudioTransport.test.ts whose gain-node index assertions broke once B7 inserted the extra output gain node between a group's input and master.

Depends on and stacks on #3289 (B4), #3288 (B6), #3287 (B3), #3286 (B2), #3278 (B1), #3277 (P2), #3276 (P1), #3275 (A2), #3274 (A1) — should merge after all of those.

Test plan

  • bun run build clean
  • packages/core full suite: 2371/2371
  • packages/studio full suite: 4260/4260
  • bunx oxfmt / bunx oxlint clean on all touched files
  • New tests: webAudioTransport.test.ts (analyser lazily created/disposed per group, groupLevel null for idle/unknown, RMS reading, clip detection), TimelineGroupBusStrip.test.tsx (Holds-line, live-drag vs release-commit, 0..2 clamp, clip/hold state machine, no-dB copy assertion)
  • Manual: play a grouped composition — the bar moves; mute the group — bar dies (mute lands in B5); drive the group volume up on hot material — "Too loud" appears and later clears

🤖 Generated with Claude Code

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Approve. Independent read at current head.

Audio graph topology is correct: output gain node between FX chain and master, with analyser tapped off the output node — honest level readings per design doc §5. Pre-allocated per-group Float32Array buffer avoids per-frame allocation. useGroupLevel throttles renders at 33ms via setTimeout without adding its own polling loop — relies on the runtime's own posting cadence.

Live/commit split mirrors FxParamRow: setLive does DOM-only write during drag, setQuiet persists with undo on release, save failure rewinds the optimistic write. STRIP_H layout constant integrates cleanly alongside TRACK_H/LANE_H.

Tests pin 7 component behaviors (slider clamping, clipping hold/decay, no-dB constraint) and 5 transport-level meter behaviors (lazy analyser creation, RMS reading, clip detection, dispose cleanup).

No issues. Ship it.

— Miga

miga-heygen
miga-heygen previously approved these changes Aug 21, 2026

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Approve. Independent read at exact head.

Bus strip (B7) architecture is correct. The input→output gain pair creates a stable anchor point for the meter — the analyser taps output, which sits after the FX chain and before master. The groupLevel method uses RMS with a per-group reusable buffer (no per-frame allocation). Clip detection at |sample| >= 0.99 is appropriate. The "Too loud" hold timer (2s after last clip) prevents flicker.

Key correctness points:

  • Analyser created lazily, one per group, on first member schedule — no wasted resources for idle groups
  • fftSize: 256 is correct for level metering (not spectrum analysis)
  • postGroupLevels only fires while playing — no idle CPU
  • The output gain node is correctly documented as the B5 mute splice point (before output, not after)
  • useGroupLevel throttles at 33ms (~30fps) using the same pub-sub pattern as liveTime.ts
  • TimelineGroupBusStrip live-writes on drag, commits on release — correct split
  • applyGroupStripHeights correctly overrides only anchor rows, leaving member rows at their natural height
  • audioGroupVolume parsed once per document via the groupInfoCache WeakMap

Tests cover: null for unknown group, one analyser per group (lazily), RMS reading, clip flag, analyser disposal on destroy, bus strip live/commit split, level bar + clip hold, no-dB design constraint.

No issues. Ship it.

— Miga

terencecho
terencecho previously approved these changes Aug 21, 2026

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Approve. Concurring with Miga's APPROVE at this SHA; adding independent audits below.

Cross-checks (unique)

  • Analyser sizing round-trip. analyser.fftSize = 256 is set BEFORE levelBuf: new Float32Array(analyser.fftSize) reads it in the _groups.set(groupId, {...}) block, so the per-group buffer is 256 samples (not the WebAudio default 2048) — matches the test mock's assertion at line 173. RMS over 256 samples at 48kHz ≈ 5.3ms window: appropriate for a level meter, not a spectrum.
  • Clip threshold honesty. Math.abs(sample) >= 0.99 (~-0.087 dBFS) — trips just below digital full-scale, so anything that would actually clip the master trips it. Correct semantic pin.
  • Playing-only gate holds. postGroupLevels() is called inside if (clock.isPlaying()) in init.ts:2919; groupIds() early-returns if empty; .filter(null) early-returns if all groups are idle. Zero-cost when nothing is grouped or playing.
  • RuntimeOutboundMessage discriminant is disjoint. type: "group-levels" is a new literal not colliding with the 10 existing message types in the union, and useTimelinePlayer.ts matches by both source === "hf-preview" AND type === "group-levels" before dispatch — no fall-through into the state branch.
  • Cache staleness shape. groupInfoCache: WeakMap<Document, ...> inherits the pre-existing groupLabelCache shape — invalidates on iframe-reload / Document replacement, not on live data-volume mutations. Not a regression (same shape as before); worth flagging on B5 (mute) if that path expects live-mutation reads.
  • Test-fix accounting. The two pre-existing webAudioTransport.test.ts failures were hardcoded gain-node index assertions (indices 0/2 → 0/3), correctly updated to reflect the new output gain node inserted between input and master. These are pin-test index shifts, not semantic-behavior changes.
  • CI at head: all 26 required + optional checks green after dedupe-by-name (Preflight, regression-shards ×9, Perf ×5, preview-parity, Test, workflow gates).

Concur on (per Miga)

Audio graph topology (input → FX/dry → output → master, analyser tapped off output), pre-allocated Float32Array buffer, useGroupLevel 33ms throttle piggybacking runtime cadence, live/commit split mirroring FxParamRow, STRIP_H layout integration, 12 test-pin coverage across component + transport.

Ship it.

— Review by tai (pr-review)

Base automatically changed from wa-23d-group-render to main August 21, 2026 16:42
@vanceingalls
vanceingalls dismissed stale reviews from terencecho and miga-heygen August 21, 2026 16:42

The base branch was changed.

B7: the group bus strip — droppable, and deliberately minimal per the
casual-user design constraints (groups doc §5): a volume slider, a level
bar that moves with the sound, and the words "Too loud" when it clips. No
dB numbers, no peak-hold readout, no routing row.

Transport (core): groupInput() now routes each group through input -> [FX
chain or dry passthrough] -> output -> master, with one AnalyserNode per
group tapped off `output` (post-FX, so the meter reads what the bus
actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId)
returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer
(no per-frame allocation), or null when the group is idle/unknown. The
runtime posts group-levels messages only while playing, piggybacking the
existing message channel rather than adding a new poll loop.

Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's
shape) fed by useTimelinePlayer's message handler via
parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms.
TimelineGroupBusStrip renders in the group row's own `∿` lane area
(STRIP_H, already sized in B2's row-height pipeline) — drag writes live
via onSetAudioGroupAttributeLive, release commits one undo entry via
onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/
timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to
stay under the 600-line cap; mirrors FxParamRow's live/commit split).
"Too loud" holds for ~2s after the last clipped block, tracked in the
component, not the transport. volumeByGroup mirrors labelByGroup in
useTimelineTrackDerivations.ts so the strip's slider round-trips the
group's own data-volume.

Fixed two pre-existing group-routing tests in webAudioTransport.test.ts
that hardcoded gain-node creation order/count — B7 inserts an extra
`output` gain node between the group's input and master (for the meter to
tap), which shifted node indices the tests asserted on directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…evel buffer non-shared

Two CI gates, both from this branch's own additions.

`File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the
group-levels branch pushed it to 605 (cap 600). Extracted the `window.message`
router — which already carried a `fallow-ignore-next-line complexity` admitting
it had outgrown its home — into `previewMessageRouter.ts`, with the fixture
lease, sender check and protocol accept-gate collapsed into one
`acceptedPreviewMessage` so the listener is a flat dispatch and the suppression
is retired rather than moved. Same branches, same refs, no behaviour change;
the file lands at 561.

`Test: runtime contract`: `levelBuf: Float32Array` resolves to
`Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and
`getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345).
Pinned the field to `Float32Array<ArrayBuffer>`, which is what
`new Float32Array(analyser.fftSize)` already produces.

Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")`
— main deleted that component in favour of `feedback/StudioFeedbackCard`, and
touching this file for the group prop put the dangling path in fallow's scope.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants